区块链学习笔记之Balsn CTF 2019 - Bank
其实这道题应该算是比较过时了,只有solidity 0.5.0 以前可能才会出现的漏洞,感觉主要是结构体未初始化造成的一个变量覆盖,以及程序流的劫持,有一点pwn的感觉在里面。所以通过这道题也是对solidity的存储机制有了一定的了解。
状态变量储存结构
参考登链社区的solidity中文文档,除了映射(mapping) 和 动态数组 的静态大小变量都是从位置 0 开始连续放置在存储(storage)中,如果可能的话,存储大小少于32字节的多个变量会被打包到一个存储插槽中(storage slot),(所以一个slot就是32字节的大小),规则如下:
- slot的第一项会以向右对齐的方式存储
- 基本类型仅使用存储他们所需字节大小的存储空间
- 如果一个slot的剩余空间不足以放下接下来的基本变量,那么它会移到下一个slot
- 结构体和数组的数据总是会占用一整个新的slot,但结构体或数组中的每一项还是会以上述规则打包。即不会出现一个slot里出现两个结构体或者两个数组的情况,即使一个结构体或数组也许仅仅占用了2字节。
还有更多比较细节的东西,就不再这篇文章里提出来了,感兴趣的读者可以去看看文档深入了解。
映射和动态数组
由于映射和动态数组的大小是不可预知的,所以他们使用keccak256来计算找到值得位置或数组的起始位置,映射和动态数组本身会根据上述规则在某个位置 $p$ 处占满一个slot(或递归的将该规则应用到映射的映射或者数组的数组),对于动态数组,此slot会存储数组中元素的数量;对于映射,这个插槽不用,但这个茅坑还是得占,这样可以使得两个映射之后会使用不同的散列分布。
对于动态数组,数组的起始位置会位于 keccak256(p)
处,对于映射,映射中的每个键对应的值会位于 keccak256(k||p)
处,(||是连接符,代码:keccak256(abi.encodePacked(k, p))
)如果这个值不是基本类型(比如是个结构体),那么就通过加偏移来确定。
例子
1 | // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.4.0 <0.9.0; |
对于上述合约,data[4][9].b
的位置为keccak256(uint256(9)|| keccak256(uint256(4)||uint256(1))) + 1
解释一下,首先 struct S { uint a; uint b; }
只是一个结构体的定义,并没有定义变量,所以不占用slot,uint x
占用slot0,然后 mapping(uint => mapping(uint => S)) data;
会占用slot 1,所以 data[4]
的值的位置就是 keccak256(uint256(4)||uint256(1))
,然后这个地方呢不是一个基本类型,是一个 mapping(uint => S)
的映射,所以这个映射占用了slot_keccak256(uint256(4)||uint256(1)),然后再去这个映射的键值为9的值,所以这个地址就是在keccak256(uint256(9)|| keccak256(uint256(4)||uint256(1)))
,再然后,这个地方仍然不是一个基本类型,是一个结构体,这个结构体里 uint a
会占用一个slot,uint b
会占用一个slot,所以 a 的偏移是 0 ,b 的偏移是 1,所以最后为keccak256(uint256(9)|| keccak256(uint256(4)||uint256(1))) + 1
。
漏洞:局部变量未初始化
如果智能合约函数声明了临时的动态数组或者sturct,而没有指定“位置”(storage 还是 memory),且没有进行初始化,那么这些变量将默认为”存储指针”,且指向slot0。
漏洞合约例子
1 | contract NameRegistrar { |
注意到该合约在register中定义了一个newRecord,未指定位置,也没有初始化,所以该结构体的指针指向slot0,如果对name赋值,将修改slot0,从而覆盖unlocked变量,如果name的最后1byte为1,那么unlocked即被修改为True,从而绕过最后的限制。
Balsn CTF 2019 - Bank
好了,搞懂前面三个问题,我们就可以来看看这个题目了。
1 | pragma solidity ^0.4.24; |
合约并不算长。首先我们看拿到flag的条件,不难注意到最后有一个sendFlag函数,会触发SendFlag事件,然后出题人那边部署的Listen监听到后就会给我们发送flag了。调用sendFlag的话,在deposit,如果是合约所有者调用的话,就会把box.callback 改成 sendFlag(顾名思义,猜测这玩意儿应该有点像那个回调函数叭),然后再调用withdraw就会触发这个box的callback,不过box有很多,需要指定一个,然后还得给一个pass,因为sendFlag被onlyPass修饰,要求(bytes12(sha3(pass)) != safeboxes[idx].hash)
,问题不大,hash和pass都是可控的。至于要求合约所有者调用,这个问题不大,注意到deposit里的 SafeBox box
,对box的声明并没有指定位置和初始化,所有该结构体指针是指向slot0的,而存储owner的地方正是在slot0,可以先排一下
1 | ----------------------------------------------------- |
然后box的是
1 | ----------------------------------------------------- |
所以我们可以控制callback,hash 两个变量的值,还有个done是0,不过没事,我们可以换账户么,换个末尾是0的就行。所以完全可以把原来的owner覆盖成我们自己。但是问题来了,他要求require(msg.value >= 100000000 ether)
,这个就比较过分了,好像有点难顶。
但是又发现,这个modifier里声明的 FailedAttempt info;
也是未指定位置和初始化的。那它也能改点东西啊。看看它的结构
1 | ----------------------------------------------------- |
他占三个slot,所以它能改到slot2,也就是safeboxes的长度。safeboxes是一个动态数组,failedLogs 是一个映射,但他们都是存储在storage上的,所以有没有可能,我是说可能,他们是可以重叠的。只要safeboxes的长度比他们各自起始位置的差值的二分之一大就可以了。也就是 keccak245(msg.address()||3) -keccak256(2) < safebox.length // 2
(因为一个box占俩slot)
重叠之后能干嘛,重叠之后 failedLogs 里的 某个 info 通过修改 triedPass 就能覆盖safeboxes里某个box的callback了。把callback覆盖成sendFlag?格局小了,那不还是得要100000000eth,直接给他跳到 emit SendFlag(msg.sender)
,pwn!那我们怎么知道emit SendFlag(msg.sender)
的位置在哪儿呢?看汇编,https://ethervm.io/decompile/ropsten/0x85B0446Dc5B5f32cbB674Dc8e49Fc27Ebaff2Ee2 根据100000000eth的特征我们找到
(EVM好像只让jump到jumpdest的地方),所以我们往070F跳。
然后这个覆盖是在修饰器里造成的,所以我们需要调用一次deposit ,转进去1 eth使得safeboxes[0] 的 callback 是 sendEther 从而方便之后调用withdraw可以触发修饰器里对info的写。
解题步骤
- 计算
target = keccak256(keccak256(msg.sender||3)) + 2
,这个是 failedLogs [msg.sender].”origin+tridPasss” 的地方,我们要改这里【注意这里有两次keccak,一次是mapping的,一次是failedLogs[]的,实际部署的时候在这里踩坑了】 - 计算
base = keccak256(2)
,这个是safeboxes的起始位置 - 计算
idx = (target-base)//2
这个是要改的位置和safeboxes开始的位置之间能塞多少个box - 如果
(target-base) % 2 == 1
,说明不是正正好塞满整数个box,那么idx += 2,我们要用到下两个box,这个box和下一个box都改不到。 - 如果
(msg.sender << (12*8)) < idx
得换一个账户,因为safeboxes的长度是用 tx.origin 去覆盖的,最后的值会是tx.origin << (12*8) + Pass
- 用 1 eth 调用一下
deposit(0x000000000000000000000000)
- 调用
withdraw(0, 0x111111111111110000070f00)
,如果step4中 (target-base) % 2 == 1,那么这一步执行两次 - 最后再调用一下
withdraw(idx, 0x000000000000000000000000)
就能触发emit SendFlag(msg.sender);事件了。
程序流程
前三步应该没有什么问题,只是一个简单的距离计算,就像pwn里面的溢出你需要算填充多少字节一样。
第四步有一个分类讨论了就,如果正好被2整除,那么就是这样子的一个情况
此时我们修改failedLogs [0] 的 pass 就能够改到 safeboxes[idx] 的 callback
但如果是不被2整除,就稍微麻烦些,storage上应该是这样
我们需要修改failedLogs [1] 的 pass 才能改到 safeboxes[idx+2] 的 callback
第六步调用deposit(0x000000000000000000000000)
,转个 1eth,此时 safeboxes[0].callback = sendEther,safeboxes[0].hash = 0x000000000000000000000000,safeboxes[0].done = false,safeboxes.value = 0.99eth
第七步调用 withdraw(0, 0x111111111111110000070f00)
,此时会调用sendEther函数,进入修饰器,由于不满足 (bytes12(sha3(pass)) != safeboxes[idx].hash)
,所以开始写info,info.idx = 0,info.time = now,info.triedPass = 0x111111111111110000070f00,info.origin = tx.origin
。注意此时info的值会修改slot0,slot1,slot2的值,所以此时owner=0,randomNumber = now,safeboxes.length = tx.origin << (12*8) + 0x111111111111110000070f00
,然后把这个info推进 failedLogs [0],但推进faileLogs[0] 的同时,也把 safeboxes[idx].callback 改成了 111111110000070f
如果之前(target-base) % 2 == 1,那么再执行一次,前面的不变,不过又把一个info推进到了 failedLogs [1] ,此时会把 safeboxes[idx+2].callback 给改了。
最后调用withdraw(idx, 0x000000000000000000000000)
,执行 box.callback(idx, pass);
,此时 box.callback 已经被劫持到了 emit SendFlag(msg.sender) 的位置,触发事件,收flag。
【然而事情并非如我所愿,实际操作的时候卡在最后一步了,】
我这里给的pass是0xdeadbe00000000000008FF00(因为我的jumpdest是08ff),此时我的账户地址是0x5B38Da6a701c568545dCfcB03FcB875f56beddC4,我查的是safeboxes[23098898392122849103790042457787377065045997405586824991915591150521413904160],返回的是数组该处的hash值为0xb03fcb875f56beddc4deadbe,就是我账户地址的后半部分和我pass的前3个字节,说明我调用withdraw后,修饰器写了info,
推进failedLogs 的同时也改了safeboxes该处的值。且属于safeboxes该处结构体的callback属性的值应该是00000000000008FF,正好8个字节,然后最后的00是属于done的。那么按理说,我们withdraw数组该处box的时候,会直接执行这个box的callback,也就是0x08ff,但是,,我失败了。
【破案了,兄弟们,搞了半天之后去问zbr,才发现,是反编译的时候,把constructor code给搞进去了,所以偏移错了,不是8ff,是89a,我直接拿着input去反编译的,第一次报错,连汇编都没出来,然后我把复制的0x给删掉了之后,字节码出来了,但是伪代码没出来
然后选择性忽略了这一行小字。他说的是我可能把constructor code(不知道具体干啥的,反正应该是部署的时候给JVM看的,也许是设定了JVM部署时要用的参数啊什么什么的,不了解,也没google到,不知道哪里能搞到权威指南看)带进去了,要删掉,通常是从第一个6080(6060)删到第二个6080(6060),日,,删了之后,伪代码也出来了
寄!
pass改成0xdeadbe000000000000089a00
起飞!【踩坑记录就不删了,警醒一下自己属于是】
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可联系QQ 643713081,也可以邮件至 643713081@qq.com
文章标题:区块链学习笔记之Balsn CTF 2019 - Bank
文章字数:3.1k
本文作者:Van1sh
发布时间:2022-02-18, 14:45:00
最后更新:2022-02-23, 09:28:41
原始链接:http://jayxv.github.io/2022/02/18/区块链学习笔记之BalsnCTF 2019 Bank/版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。